Skip to content

feat(cashu): add_cashu_escrow_action lock handler — Track A TA-1#829

Merged
grunch merged 5 commits into
mainfrom
feat/cashu-ta1-lock-handler
Jul 24, 2026
Merged

feat(cashu): add_cashu_escrow_action lock handler — Track A TA-1#829
grunch merged 5 commits into
mainfrom
feat/cashu-ta1-lock-handler

Conversation

@grunch

@grunch grunch commented Jul 20, 2026

Copy link
Copy Markdown
Member

Stacked on #828 (CF-5). Review/merge CF-5 first; the base will retarget to main once CF-5 lands. The diff shown here is TA-1 only.

What & why

The first functional Cashu flow (docs/cashu/02-track-a-lock.md, TA-1): fills in the CF-5 stub add_cashu_escrow_action with the full escrow-lock algorithm. A cashu-mode node now accepts a seller's AddCashuEscrow, fully validates the 2-of-3 token against the mint and the order's trade keys, atomically advances WaitingPayment → Active, publishes the updated order event, and notifies the buyer to send fiat.

Validate-fully-then-commit discipline (same as release_action): compute/verify first, persist second, notify last. A validation or persistence failure leaves the order exactly as it was.

Algorithm (§4)

# Step Reject reason
1 resolve the order
2 sender must be the order's seller trade key InvalidPeer
3 status must be WaitingPayment NotAllowedByStatus
4 extract the CashuLockProof InvalidCashuToken
5 proof.mint_url == configured mint (cheap pre-check) InvalidMintUrl
6 bind {P_B, P_S, P_M} to this order (reject mismatched proof keys; derive the mint-validation keys from the order, never the proof) InvalidCashuToken
7 verify_escrow_token (2-of-3 + seller-recovery locktime floor + mint + amount + DLEQ + unspent) InvalidCashuToken / CashuMintUnavailable
8 update_order_cashu_escrow CAS (lock + advance in one write); zero rows ⇒ idempotent no-op
9 publish the Active order event (best-effort)
10 notify buyer + seller with CashuEscrowLocked

The escrow token locks order.amount exactly (Option 2 — the Mostro fee is a separate token added in TA-1f).

Security core (step 6)

The 2-of-3 must lock to the keys Mostro already holds for the order, never attacker-chosen keys. The handler both rejects a proof whose stated buyer/seller/mostro_pubkey disagree with the order, and derives the {P_B, P_S, P_M} handed to verify_escrow_token from the order (the seller is event.sender, already authorised in step 2) — so the proof's own key fields can never widen the accepted set.

Tests

  • Deterministic pre-mint rejections: wrong sender ⇒ InvalidPeer; wrong status; missing proof ⇒ InvalidCashuToken (order untouched).
  • cashu_reason error mapping (mint-unreachable ⇒ retryable CashuMintUnavailable; bad token ⇒ InvalidCashuToken; bad URL ⇒ InvalidMintUrl).
  • The CF-5 dispatch_cashu gate test is updated: AddCashuEscrow is no longer in the InvalidAction list (it runs the real handler now).

Follow-up — mint-backed integration test

The happy-path (valid lock → Active + buyer notified) and the replay no-op require constructing a real 2-of-3 P2PK token (locktime + refund=[P_S] + DLEQ) via a wallet mint+swap against the CF-3 nutshell mint. The CF-3 harness (tests/common/mod.rs) currently ships only connectivity helpers (mint_url_from_env, wait_for_mint, mint_get_json) — no 2-of-3 token builder yet. That builder + the env-gated #[ignore] happy-path/replay tests are a focused follow-up, not folded in here to keep the PR reviewable.

Checklist

  • cargo fmt --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test — 1026 passed
  • Off-by-default: no behaviour change when Cashu is disabled

Depends on #828 (CF-5). Refs: docs/cashu/02-track-a-lock.md (TA-1).


🧪 Manual testing — step by step

Prereqs: the same Cashu-mode mostrod setup as CF-5 (#828) — the throwaway mint up (docker compose -f docker-compose.cashu.yml up -d, http://127.0.0.1:3338) and [cashu] enabled = true. This branch is stacked on CF-5, so check it out (it contains CF-5 + TA-1). Run with RUST_LOG=mostro=info.

Scope note. TA-1 is the lock handler. Driving an order to WaitingPayment through the normal flow needs the Cashu take branch (TA-2 / #830, higher on the stack), and building a valid 2-of-3 escrow token needs a Cashu-capable client. So the interactive end-to-end lock is exercised once TA-2 is also checked out + a token-building client is available. On this branch the deterministic rejection paths and the mint-backed library checks are what you verify directly.

1. Deterministic rejection paths (no token needed)

Seed a WaitingPayment sell order for a known seller trade key (create+take on the TA-2 stack, or insert a row directly). Then submit AddCashuEscrow and confirm the CantDoReason:

  1. Wrong sender — submit from a key that is not the order's seller → CantDo(InvalidPeer). ✔️
  2. Wrong status — submit against an order that is not WaitingPaymentCantDo(NotAllowedByStatus). ✔️
  3. No lock proof — submit AddCashuEscrow with a non-CashuLockProof payload → CantDo(InvalidCashuToken). ✔️
  4. Wrong mint — a proof whose mint_url differs from the node's configured mint → CantDo(InvalidMintUrl). ✔️

The order is left unchanged after every rejection (status still WaitingPayment, cashu_escrow_token still NULL). ✔️

2. Unit + mint-backed library checks

  1. Deterministic unit tests (offline):
    cargo test --bin mostrod add_cashu_escrow
    Expected: the rejection-path + cashu_reason mapping tests pass. ✔️
  2. Mint-backed CashuClient::verify_escrow_token checks against the live mint:
    CASHU_TEST_MINT_URL=http://127.0.0.1:3338 cargo test cashu -- --ignored --nocapture
    Expected: the DLEQ / unspent / condition checks the lock handler composes pass against the running nutshell mint. ✔️

3. Happy-path lock (full stack — TA-2 + token-building client)

  1. On the full Track A stack, take a Cashu order (TA-2) so it sits in WaitingPayment, then have the seller's client build the 2-of-3 token (locktime + refund=[P_S], DLEQ) and submit AddCashuEscrow.
  2. Expected: the order advances WaitingPayment → Active in one atomic write, the updated order event is published, and both parties receive CashuEscrowLocked (the buyer's cue to send fiat). A replayed submission is a safe no-op (matches zero rows). ✔️

Summary by CodeRabbit

  • New Features

    • Added full Cashu “Track A” escrow-lock handling for eligible orders, including strict payload/token validation, seller authorization, and automatic progression from WaitingPayment → Active.
    • Added idempotent replay behavior that re-sends buyer/seller notifications for already-active locks without duplicating escrow redemption.
    • Enforced escrow-token uniqueness so the same token can’t be used for multiple orders.
  • Bug Fixes

    • Improved concurrent-submission handling and clarified that unsafe replay/no-op scenarios are rejected as InvalidCashuToken.
  • Documentation

    • Updated escrow-lock correctness and idempotency guarantees.
  • Tests

    • Expanded coverage for replay, cross-order reuse rejection, and concurrency/CAS outcomes.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c0eb23f-1e42-44aa-9cb3-cb4b971b0b70

📥 Commits

Reviewing files that changed from the base of the PR and between 18cc7d6 and 9987ba1.

📒 Files selected for processing (5)
  • docs/cashu/02-track-a-lock.md
  • migrations/20260724120000_cashu_escrow_token_unique.sql
  • src/app/add_cashu_escrow.rs
  • src/cashu/e2e_lock.rs
  • src/cashu/mod.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/cashu/02-track-a-lock.md
  • src/app/add_cashu_escrow.rs

Walkthrough

Track A now implements Cashu escrow locking with validation, atomic persistence, token uniqueness, replay handling, notifications, database protections, and live-mint integration coverage. Dispatch expectations and Track A documentation are updated accordingly.

Changes

Cashu escrow lock

Layer / File(s) Summary
Atomic escrow-token uniqueness
src/db.rs, migrations/20260724120000_cashu_escrow_token_unique.sql
Database queries, CAS predicates, a partial unique index, and tests prevent escrow-token reuse across orders.
Escrow-lock handler
src/app/add_cashu_escrow.rs
add_cashu_escrow_action validates authorization, state, proof, mint conditions, amount, and token status before persisting the lock and notifying both parties.
Replay and integration validation
src/app/add_cashu_escrow.rs, src/cashu/e2e_lock.rs, src/app.rs, src/cashu/mod.rs, docs/cashu/02-track-a-lock.md
Tests and documentation cover replay recovery, CAS outcomes, stale states, dispatch behavior, concurrency guarantees, and an ignored live-mint flow.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Seller
  participant Handler
  participant Mint
  participant Database
  participant Buyer
  Seller->>Handler: Submit CashuLockProof
  Handler->>Database: Check token usage
  Handler->>Mint: Verify token and lock conditions
  Mint-->>Handler: Verification result
  Handler->>Database: Atomically store escrow and set Active
  Database-->>Handler: Commit or classified zero-row result
  Handler->>Buyer: Enqueue CashuEscrowLocked
Loading

Possibly related PRs

Poem

A rabbit guards a token bright,
Locking escrow snug and tight.
Replays safely hop anew,
While database checks them too.
Buyer and seller cheer—
No fee token spends twice here!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the new Cashu escrow lock handler and its Track A TA-1 scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cashu-ta1-lock-handler

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2f032a652

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/app/add_cashu_escrow.rs Outdated
Comment on lines +148 to +152
let locked = update_order_cashu_escrow(
pool,
order.id,
&configured_mint,
&proof.token,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject escrow proofs already locked to another order

This CAS is scoped only to the current order.id; when the same buyer/seller trade keys and amount are used on another order, the exact same unspent Cashu token passes the mint/key/amount checks again and can be stored on both orders. Because the mint only sees the proofs as unspent until one redemption, this can mark multiple trades Active while only one redeemable escrow exists; add a cross-order proof/Y uniqueness check before accepting the token.

Useful? React with 👍 / 👎.

grunch added 3 commits July 24, 2026 16:07
Fill in the CF-5 stub with the full escrow-lock algorithm
(docs/cashu/02-track-a-lock.md §4). A `cashu`-mode node now accepts a
seller's `AddCashuEscrow`, fully validates the 2-of-3 token against the mint
and the order's trade keys, atomically advances `WaitingPayment → Active`,
publishes the updated order event, and notifies the buyer to send fiat.

Validate-fully-then-commit discipline (same as `release_action`):

1. resolve the order;
2. authorise the sender == the order's seller trade key (else `InvalidPeer`);
3. require `WaitingPayment` status;
4. extract the `CashuLockProof` (absent ⇒ `InvalidCashuToken`);
5. bind the mint: `proof.mint_url` must equal the configured mint
   (`InvalidMintUrl`) — a cheap pre-check; step 7 enforces the authoritative
   token↔mint binding;
6. bind `{P_B, P_S, P_M}` to THIS order — reject a proof whose stated keys
   disagree with the order, and derive the keys handed to the mint from the
   order (never from the proof), so the 2-of-3 can only lock to keys Mostro
   already holds;
7. `verify_escrow_token` (2-of-3 + seller-recovery locktime floor
   `now + escrow_locktime_days` + mint + amount + DLEQ + unspent); mint
   unreachable ⇒ `CashuMintUnavailable`, malformed ⇒ `InvalidCashuToken`;
8. `update_order_cashu_escrow` CAS (lock + status advance in one write);
   zero rows ⇒ idempotent no-op (replay/concurrent), no notification;
9. publish the Active order event (best-effort, logged on failure);
10. notify buyer + seller with `CashuEscrowLocked`.

The escrow token locks `order.amount` exactly (Option 2 — the Mostro fee is a
separate token added in TA-1f).

Tests: the deterministic pre-mint rejection paths (wrong sender ⇒
`InvalidPeer`, wrong status, missing proof ⇒ `InvalidCashuToken`) and the
`cashu_reason` error mapping. The mint-backed happy-path + replay tests are a
follow-up: they need a 2-of-3 token builder in the CF-3 harness (which today
ships only connectivity helpers).

Depends on CF-5 (dispatch seam + cashu_client). Base: feat/cashu-cf5-boot-integration
Refs: docs/cashu/02-track-a-lock.md (TA-1)
TA-1 implements AddCashuEscrow, so dispatch_cashu no longer routes it to
InvalidAction — it runs the real lock handler. Remove it from the
'blocks every order-lifecycle action' assertion list.
… guard

Two findings from the automated review of #829.

Replay recovery (step 3b). The step-10 notifications are sent after the CAS
commits, so a crash or a lost send queue in between left the escrow funded and
the buyer never cued to send fiat — and the step-3 status check rejected every
retry, making the documented idempotent retry path unreachable. An order this
handler already locked (escrow stored, still Active) whose submitted token
matches the stored one now replays the notifications and returns Ok(()): no
mint round-trip, no second write. A different token on a locked order is a
second funding the escrow can never honour, so it is rejected. The exception is
scoped to Active, so a stale "escrow locked, send fiat" is never replayed onto
a trade that has moved on.

Cross-order token uniqueness (step 6b). The 2-of-3 condition commits to trade
keys, not to an order id, and the mint reports proofs unspent until the first
redeem — so two orders reusing the same trade keys could both validate the very
same token and go Active against one redeemable escrow. The handler now rejects
a token already escrowed elsewhere before the mint round-trip, and the CF-4 CAS
carries a NOT EXISTS leg that closes the check-then-act race atomically. A CAS
that matches zero rows because another order took the token is reported as
InvalidCashuToken rather than a silent no-op. This is token-level uniqueness;
proof-level (Y = hash_to_curve) uniqueness arrives with TA-1f.

Track A §4 of the spec is updated to match, along with its Definition of Done.
@grunch
grunch force-pushed the feat/cashu-ta1-lock-handler branch from 760467f to 18cc7d6 Compare July 24, 2026 19:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/db.rs (1)

826-861: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add an index for the Cashu escrow token uniqueness predicate.

cashu_escrow_fields only adds the columns, while update_order_cashu_escrow and cashu_escrow_token_in_use both filter orders by cashu_escrow_token; add a migration index for that column to avoid full orders table scans on lock/check attempts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db.rs` around lines 826 - 861, Add a database migration creating an index
on orders.cashu_escrow_token, alongside the schema change represented by
cashu_escrow_fields. Ensure the index supports the token lookups in
update_order_cashu_escrow and cashu_escrow_token_in_use without changing their
query behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/add_cashu_escrow.rs`:
- Around line 244-263: Update the !locked CAS-failure handling in the escrow
lock flow to first verify that order.id is locked with the submitted proof.token
before treating the result as an idempotent no-op. If the order is locked with a
different token, reject with InvalidCashuToken; retain the existing rejection
for the token being locked to another order and the no-op behavior only when the
stored token matches or the status has moved on.

---

Nitpick comments:
In `@src/db.rs`:
- Around line 826-861: Add a database migration creating an index on
orders.cashu_escrow_token, alongside the schema change represented by
cashu_escrow_fields. Ensure the index supports the token lookups in
update_order_cashu_escrow and cashu_escrow_token_in_use without changing their
query behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 798b24f0-9e03-42c6-bb24-231dfe3f34da

📥 Commits

Reviewing files that changed from the base of the PR and between a99799e and 18cc7d6.

📒 Files selected for processing (4)
  • docs/cashu/02-track-a-lock.md
  • src/app.rs
  • src/app/add_cashu_escrow.rs
  • src/db.rs

Comment thread src/app/add_cashu_escrow.rs
grunch added 2 commits July 24, 2026 16:56
Two follow-up review findings on the TA-1 lock handler.

A compare-and-set that matched zero rows was treated as a no-op whenever the
token was not held by another order. That misses one case: a concurrent
submission may have locked THIS order with a *different* token in the window
since the order was fetched, which means this submission was never accepted —
answering Ok(()) leaves the seller believing an untouched, still-unspent token
is escrowed. The classification now lives in `classify_zero_row_cas`, which
rejects both lost races (token taken by another order; this order locked with
another token) and keeps the no-op for a duplicate delivery of the winning
token or a status that simply moved on. Extracting it also makes the branch
unit-testable — the call site sits past the mint round-trip and cannot be
reached from tests.

The handler's two token lookups — the CAS's NOT EXISTS leg and
cashu_escrow_token_in_use — were full table scans of `orders`. Adds a partial
index on cashu_escrow_token, unique for the same reason as
idx_bonds_parent_child_unique: it cannot change current behaviour (the CAS
never attempts a duplicate write) but makes the one-order-per-token invariant
survive a regression that drops the NOT EXISTS predicate.

Track A §4 step 8 is updated to match.
The unit tests stop before the mint round-trip, so steps 5-8 of the handler —
mint binding, pubkey binding, verify_escrow_token, the CAS — had no coverage at
all. tests/cashu_mint.rs cannot fill the gap either: mostro is a binary crate
with no lib target, so an integration test cannot reach the handler.

Adds an in-tree #[ignore]d, env-gated harness that mints a real 2-of-3 escrow
token (data = P_S, pubkeys = [P_B, P_M], n_sigs = 2, locktime, refund = [P_S])
at a live mint and drives add_cashu_escrow_action with it end to end: happy path
WaitingPayment -> Active, then the two guards this PR added — replay recovery
and cross-order token reuse.

It mints straight into the P2PK condition rather than minting plain ecash and
swapping, so the token value stays exactly the order amount on a mint that
charges an input fee (mint.cubabitcoin.org's sat keyset charges 100 ppk, and a
swap would eat into the amount the handler checks for equality).

Safety, since it can run against a real mint: the three keys are generated per
run and written to disk BEFORE anything is minted — they are the only way to
redeem the escrow — and the locktime floor defaults to 1 day rather than the
15-day production default, so the seller-recovery path opens quickly.

Verified against the throwaway nutshell mint: all three checks pass. A plain
cargo test still reports it as ignored.
@grunch
grunch merged commit ec4a046 into main Jul 24, 2026
9 checks passed
@grunch
grunch deleted the feat/cashu-ta1-lock-handler branch July 24, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant